Skip to content

fix(operations): replace the component removal recipe that undoes itself - #684

Open
Aleksei Sviridkin (lexfrei) wants to merge 4 commits into
mainfrom
fix/component-removal-recipe
Open

Aleksei Sviridkin (lexfrei) wants to merge 4 commits into
mainfrom
fix/component-removal-recipe

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

kubectl delete hr -n <namespace> <component> is documented as the way to remove an installed component. It removes nothing, and it is not harmless. The Package that produced the release owns the HelmRelease through a controller ownerReference, and the operator watches them with Owns(&helmv2.HelmRelease{}), so the delete fires the reconcile that recreates it. buildHelmReleaseSpec sets no uninstall policy, so Flux's default applies to an unsuspended HelmRelease and the release is uninstalled on the way out. The workloads come back with the reinstall. Anything the uninstall destroyed does not, and the restored release hides that it happened.

Deleting the Package is what cascades into the uninstall deliberately. The disabledPackages entry has to land first, or the next platform upgrade renders the Package again and the removal undoes itself one level up. The page checks that by reading the list back from the cozystack-platform HelmRelease, since the operator copies the Platform Package's values into that HelmRelease's spec.values. The obvious alternatives go green before the edit has landed. A condition wait on that HelmRelease passes while the operator has not copied the values across, because metadata.generation has not moved and --for=condition= only blocks while observedGeneration is behind it. Re-reading the Package after deleting it returns NotFound either way, and nothing holds a finalizer on the Package, so the delete returns before the uninstall runs.

The completion signal is a kubectl wait --for=delete on the HelmRelease, and that needs a name and a namespace the Package name does not give: both come from the PackageSource install block. The operator labels every HelmRelease it renders with the Package that owns it, so a kubectl get helmrelease --all-namespaces --selector cozystack.io/package=<name> before the delete produces both values and covers a Package that renders several releases. The same listing prints spec.suspend, because helm-controller skips the uninstall for a suspended HelmRelease and only drops its finalizer, which would leave everything the release installed behind with nothing managing it. The page says that, and says that --for=delete exits 0 for a name that was never there.

What the uninstall takes is stated above the command, since making the recipe work is what arms it. packages/system/metallb/charts/metallb/charts/crds/templates/crds.yaml renders its CRDs as ordinary subchart templates with no resource-policy annotation, so helm uninstall deletes them and the apiserver cascades every custom resource of those kinds with them, IPAddressPool, L2Advertisement and BGPPeer among them. From v1.5 the frr-k8s subchart adds its own CRDs under another group, so the warning points at the chart and its subcharts rather than listing kinds. cert-manager-crds sets crds.keep and its CRDs survive the same uninstall. Through v1.4, cozystack-basics renders the tenant-root Namespace the same way, so the warning on those pages says its removal takes the root tenant. From v1.5 that namespace carries the platform.cozystack.io/no-delete label instead, which a ValidatingAdmissionPolicy turns into a denied DELETE. That is a survival mechanism independent of the annotation, so the v1.5, v1.6 and next warnings name the label and how to drop it.

v1.0 gets different text, not a copy. That line never got the helm.sh/resource-policy: keep annotation on rendered Packages (none in packages/core/platform/templates/_helpers.tpl at v1.0.8), so adding a name to disabledPackages already removes an installed component there, in one step and with no confirmation. The page says that, carries the same warning, and drops the kubectl delete hr recipe. Its listing has to be taken before the edit, since the Package the selector needs goes away with the component.

keep reached v1.1 and v1.2 mid-line, in v1.1.5 and v1.2.1. Those two pages state the single-step behaviour of v1.1.0 to v1.1.4 and v1.2.0 in its own paragraph ahead of the two-step recipe.

v0 keeps its recipe, which predates Package objects and works there, and now carries the warning: the MetalLB chart renders its CRDs without resource-policy on every 0.x line as well.

The v1.0 and v1.1 disabledPackages examples, on the components page and in the Hetzner guide, listed bare metallb / hetzner-robotlb. Those match nothing against the fully-qualified names the chart feeds the helper, so they're qualified now. The same pages pointed at kubectl get package as the way to find those names; an optional package has no Package object until it is already in enabledPackages, so they point at kubectl get packagesource instead.

Not taken: cozypkg del resolves dependents and deletes in reverse topological order, which raw kubectl delete does not, but whether that subcommand exists varies across the version trees.

hugo --gc --minify builds clean and hack/check-i18n.sh check passes.

@netlify

netlify Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for cozystack ready!

Name Link
🔨 Latest commit f621e84
🔍 Latest deploy log https://app.netlify.com/projects/cozystack/deploys/6ab2f29014c7d50008425b9d
😎 Deploy Preview https://deploy-preview-684--cozystack.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6efe329e-0eca-4872-95c4-c2bafc26191d


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

NOT LGTM

Reviewed at 2e750d69f against merge-base 257bbbbe3.

The fix itself is sound. I re-derived every factual claim in the PR body against the platform source at the matching tags rather than taking them on trust: Package ownership of the HelmRelease, Flux's default deletion behaviour, metallb's CRDs carrying no resource-policy, cert-manager's contrasting crds.keep, and the exact patch releases where the keep annotation appears. All of it holds. What blocks is that v1.0 still documents the recipe being replaced everywhere else, with no warning on the page.

Findings

  • [MINOR] content/en/docs/next/operations/configuration/components.md:83, Warning's named CRD list undercounts the blast radius under metallb.frrk8s.enabled: true
  • [MINOR] content/en/docs/next/operations/configuration/components.md:89, No signal for when the destructive uninstall has actually finished
  • [MAJOR] content/en/docs/v1.0/operations/configuration/components.md:70, the recipe this PR replaces is still live on v1.0, with no warning

Claim mismatches

[UNVERIFIABLE] "hugo --gc --minify builds clean". I could not reproduce it here: the build hung on Hugo Modules resolution, with the docsy module never appearing in GOMODCACHE despite proxy.golang.org being reachable, and was killed after two attempts. The sandbox Hugo is v0.153.2, below the 0.164.0 extended that CONTRIBUTING.md requires, which may be why. Secondary evidence supports the claim without settling it: the {{% alert %}} shortcode form used in the diff appears identically in 392 other places on the site, and open and close tags balance 1:1 in all seven touched files. A gap on my side, not a defect.

Caveats

  • v0/operations/configuration/components.md also still carries kubectl delete hr, and that one is correctly out of scope. v0 predates the Package mechanism, so the removal path there is different, and CONTRIBUTING.md explicitly demotes it to legacy-only fixes. v1.0 has neither property.
  • hack/check-i18n.sh fails today on the four stale translations the PR body names (content/{de,hi,ru,zh-cn}/_index.md). None are touched here, so it is pre-existing and matches the body's own disclosure.

Recommended follow-ups

  • File the tracking issue for the v1.0 gap if it does not get folded into this PR.
  • Consider replacing the hand-enumerated kind list in the Warning with a pointer to whatever CRDs the component's chart bundles, so a component like metallb with frr-k8s does not need the list maintained per component.

Findings not anchored to changed lines

These reference code outside this PR's diff (unchanged files, or lines outside a hunk), so GitHub cannot render them inline.

[MAJOR] content/en/docs/v1.0/operations/configuration/components.md:70 the recipe this PR replaces is still live on v1.0, with no warning

v1.0/operations/configuration/components.md:70 still tells the reader to run kubectl delete hr -n <namespace> <component>, and that page carries no warning at all. v1.0/install/providers/hetzner.md:347 still has the bare - metallb under disabledPackages that this PR fixes on the v1.1 equivalent, and the platform matches disabledPackages against the fully-qualified cozystack.metallb, so the bare name does nothing.

On v1.0 the rendered Package never carried helm.sh/resource-policy: keep, which is the same reason the PR gives for the v1.1/v1.2 caveat. So the v1.0 page documents the destructive path with zero disclosure:

$ grep -n 'kubectl delete hr' content/en/docs/v1.0/operations/configuration/components.md
70:kubectl delete hr -n <namespace> <component>
$ grep -c 'alert title="Warning"' content/en/docs/v1.0/operations/configuration/components.md
0
$ grep -n -A1 'disabledPackages' content/en/docs/v1.0/install/providers/hetzner.md | sed -n '3,4p'
346:              disabledPackages:
347-                - metallb

$ for t in v1.0.8 v1.1.4 v1.1.5; do printf '%-8s ' $t; \
    git show $t:packages/core/platform/templates/_helpers.tpl | grep -c resource-policy; done
v1.0.8   0
v1.1.4   0
v1.1.5   2

v1.0 is not demoted. hugo.yaml carries exactly one hidden: true and it belongs to next, not to v1.0; CONTRIBUTING.md demotes only v0 ("Treat v0/ as legacy"), and its own routing table says a fix that also applies to an older supported version gets copied into each relevant vX.Y/.

The PR body scopes this out on purpose and explains why (v1.0 needs a different correction rather than a copy, since disabledPackages alone still prunes the Package there). That reasoning is sound, and I verified it. What is missing is the follow-up: no tracking issue, no companion change, and no notice on the page. Either extend this PR to v1.0 with the version-scoped variant of the same two paragraphs, or file the issue before merge.

Then delete the Package object. `kubectl get packages` lists the names.

{{% alert title="Warning" color="warning" %}}
Deleting the Package uninstalls the component's Helm release, and that destroys more than the workloads. Anything the chart rendered as an ordinary template without `helm.sh/resource-policy: keep` goes with the release, CRDs and namespaces included, and Kubernetes deletes every custom resource of those CRD kinds along with them. Removing `cozystack.metallb` takes the MetalLB CRDs and with them every IPAddressPool, L2Advertisement, BGPPeer and the rest of those kinds cluster-wide; removing `cozystack.cozystack-basics` takes the `cozy-public` namespace and everything stored in it. Back up anything you still need first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] Warning's named CRD list undercounts the blast radius under metallb.frrk8s.enabled: true

The alert names IPAddressPool, L2Advertisement, BGPPeer as the destroyed kinds. packages/system/metallb/charts/metallb/charts/frr-k8s/charts/crds/templates/*.yaml renders four more CRDs (frrconfigurations, frrnodestates, bgpsessionstates, frrk8sconfigurations) under a different group, frrk8s.metallb.io, as a nested subchart of the same cozystack.metallb release, and metallb.frrk8s.enabled: true is a documented toggle. The preceding generic sentence already covers this correctly, so this is not wrong, just incomplete for BGP-mode operators reading only the named list. Same text is duplicated identically at v1.1:78, v1.2:83, v1.3:83, v1.4:83, v1.5:83, v1.6:83.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped the kind list. The sentence now points at the chart and says subcharts count, so it does not need updating per component.

One scope correction: frr-k8s only landed in packages/system/metallb at v1.5.0, so v1.1 through v1.4 never shipped those four CRDs. The new wording holds on all of them anyway.

The namespace a component installs into is the exception: the operator applies that one itself, outside the component's release and with no ownerReference, so the uninstall never had it to remove.

```bash
kubectl delete package.cozystack.io <package-name>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] No signal for when the destructive uninstall has actually finished

internal/operator/package_reconciler.go registers no finalizer on Package (verified: the only finalizer usage in the tree belongs to an unrelated tapmaterializer reconciler), so kubectl delete package.cozystack.io returns as soon as the Package object itself is gone. The owned HelmRelease's deletion, and the helm uninstall inside it that destroys the CRDs/namespaces the Warning describes, proceeds asynchronously afterward via the ownerReference cascade. The recipe has no follow-up check for confirming the destructive step is actually done. Same text is duplicated identically at v1.1:84, v1.2:89, v1.3:89, v1.4:89, v1.5:89, v1.6:89.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a wait after the delete:

kubectl wait --for=delete helmrelease/<component> --namespace <namespace> --timeout=10m

with a line saying why the delete returns early.

@lexfrei

Copy link
Copy Markdown
Contributor Author

IvanHunters folded v1.0 into this PR instead of filing the issue.

It needed different text, not a copy. At v1.0.8 packages/core/platform/templates/_helpers.tpl has no helm.sh/resource-policy: keep, so disabledPackages alone already removes an installed component there: one step, no second command, no confirmation. The page now says that, carries the same warning, and the kubectl delete hr recipe is gone. Bare metallb / hetzner-robotlb in v1.0 hetzner.md and the components example are qualified too.

i18n-lint was red on four stale home-page translations, nothing to do with this PR. Fixed in e25136bd: the English home page gained a Live Demo block and de/hi/ru/zh-cn never got it.

@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/component-removal-recipe branch 2 times, most recently from 4bdf52e to 35a2827 Compare September 22, 2026 19:29
@lexfrei

Copy link
Copy Markdown
Contributor Author

IvanHunters I rebased onto main, so the SHAs in my earlier replies are gone. The i18n fix I referenced as e25136bd landed on main separately and is no longer part of this PR. The frr-k8s kind list and the missing wait are now in bba3f9cb. The v1.0 correction is in 35a28272.

Two changes since you looked. On v1.1 and v1.2 the note about v1.1.0 to v1.1.4 and v1.2.0 sat in the middle of the two-step instructions and read as if one of the steps applied to the old patches, so it's now a separate paragraph ahead of the steps. On v1.0 to v1.4 the warning now says that removing cozystack.cozystack-basics takes tenant-root along with cozy-public. Until v1.5.0 that chart renders the tenant-root Namespace with no keep, and v1.5.0 adds the platform.cozystack.io/no-delete label that the admission policy enforces.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

NOT LGTM

The per-version research behind this is genuinely good, and all three findings from my last round are closed: the resource-policy: keep boundaries are right at every tag, the prefixed cozystack.* names fix a real no-op on v1.0/v1.1, and the new warning covers the MetalLB subchart CRDs. What blocks it is the new procedure's end: the completion check cannot fail, the sentence the recipe rests on is conditional, the Warning's survival rule is contradicted by the very object this PR removed from it, and v0 keeps the old command with no warning at all.

Findings

  • [MAJOR] content/en/docs/next/operations/configuration/components.md:95, the completion check cannot fail on a wrong guess, and its two placeholders are underivable
  • [MAJOR] content/en/docs/next/operations/configuration/components.md:100, "Flux uninstalls the release when the HelmRelease goes away" is conditional, and Cozystack reaches the other branch itself
  • [MAJOR] content/en/docs/next/operations/configuration/components.md:83, resource-policy: keep is not the whole survival rule from v1.5.0, and this PR's own edit is the counterexample
  • [MAJOR] content/en/docs/v1.0/operations/configuration/components.md:70, the v0 tree keeps the bare command and gets none of the warning
  • [MINOR] content/en/docs/v1.1/operations/configuration/components.md:44, kubectl get package cannot list a name that is not enabled yet

Caveats

  • On the v1.0, v1.1 and v1.2 trees the destructive step is the values edit itself, and those pages get no completion signal at all while the newer ones now do. The same kubectl wait would work there, subject to the first finding.
  • Two statements in the PR body do not hold on this head. hack/check-i18n.sh check exits 0 here, so i18n-lint is not red. And a condition wait on the platform HelmRelease is not useless: --for=condition= has been generation-gated since kubectl v1.21 and Flux stamps observedGeneration on the condition, so kubectl wait hr/cozystack-platform -n cozy-system --for=condition=Ready=true is a real gate after the jsonpath check.
  • I checked whether the guardrail's Capabilities gate opens a hole on clusters below Kubernetes 1.30, where the policy is skipped entirely and tenant-root would go with the release after all. It does not reach a supported install: the v1.5.0 changelog requires Kubernetes 1.33+ on the management cluster from that release on.

Nothing holds a finalizer on the Package, so this command returns as soon as the object is gone and the uninstall it triggers runs afterwards. Wait on the HelmRelease to know the destructive part has finished:

```bash
kubectl wait --for=delete helmrelease/<component> --namespace <namespace> --timeout=10m

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] the completion check cannot fail on a wrong guess, and its two placeholders are underivable

kubectl wait --for=delete suppresses not-found unconditionally, so a name the reader guessed wrong exits 0 and prints nothing, which is indistinguishable from the object having been removed. In kubectl v1.34.1:

// pkg/cmd/wait/wait.go:370-372, 379-380
isForDelete := strings.ToLower(o.ForCondition) == "delete"
if visitor, ok := visitor.(*resource.Result); ok && isForDelete {
	visitor.IgnoreErrors(apierrors.IsNotFound)
}
...
if visitCount == 0 && !isForDelete {
	return errNoMatchingResources

There is no --ignore-not-found to turn that off, and the asymmetry is the trap: a real object prints condition met, a wrong one prints nothing and still exits 0.

The reader's only handle is the Package name from kubectl get packages, e.g. cozystack.metallb. Neither placeholder follows from it. The HelmRelease name and namespace come from the PackageSource install block, which this page never mentions:

$ git show origin/main:packages/core/platform/sources/metallb.yaml | sed -n '10,25p'
    - name: metallb
      path: system/metallb
      install:
        privileged: true
        namespace: cozy-metallb
        releaseName: metallb

There is also no naming rule to fall back on, because one Package can render several releases. And the namespace a reader is most likely to substitute is the cozy-system printed on this page's own Package example.

The operator already labels every HelmRelease it generates with the Package that owns it, at every tag from v1.0.8 to main:

$ git grep -n 'cozystack.io/package' origin/main -- internal/operator/package_reconciler.go
264:		labels["cozystack.io/package"] = pkg.Name
$ for t in v1.0.8 v1.1.7 v1.3.6 v1.6.3; do git grep -c 'cozystack.io/package' $t -- internal/operator/package_reconciler.go; done
2
2
2
2

So the step before the delete can produce both values and cover a multi-release Package at the same time:

kubectl get helmrelease --all-namespaces --selector cozystack.io/package=<package-name>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f621e84. The step before the delete lists the releases the Package owns and prints namespace, name and spec.suspend as columns, so both values come out of the listing and a Package that renders several releases is covered.

kubectl get helmrelease --all-namespaces --selector cozystack.io/package=<package-name> \
  --output custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,SUSPENDED:.spec.suspend'

The line under the wait says --for=delete exits 0 for a name that was never there, so the values have to come from the listing rather than a guess. v1.0 gets the same listing, taken before the values edit, because there the Package goes away with the component.


Deleting the Package while the platform values still render it means the next platform upgrade brings it back, undoing the removal one level up. Nothing reports this at the time: the delete succeeds either way and the Package reappears whenever that upgrade happens to run.

`kubectl delete hr` is not a lighter-weight version of this. Flux uninstalls the release when the HelmRelease goes away, so it destroys the same CRDs and custom resources, and then the Package recreates the HelmRelease and the chart reinstalls. The workloads come back, the custom resources do not. If you have run it before, those custom resources are already gone and have to be recreated from your own manifests or a backup.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] "Flux uninstalls the release when the HelmRelease goes away" is conditional, and Cozystack reaches the other branch itself

The paragraph states this flat, and the wait above rests on it. The pinned controller is ghcr.io/fluxcd/helm-controller:v1.5.0 (internal/fluxinstall/manifests/fluxcd.yaml:8097), and at that tag the uninstall is gated on suspend while the finalizer removal is not:

// helm-controller v1.5.0, internal/controller/helmrelease_controller.go
func (r *HelmReleaseReconciler) reconcileDelete(ctx context.Context, obj *v2.HelmRelease) (ctrl.Result, error) {
	// Only uninstall the release and delete the HelmChart resource if the
	// resource is not suspended.
	if !obj.Spec.Suspend {
		if err := r.reconcileReleaseDeletion(ctx, obj); err != nil {
			return ctrl.Result{}, err
		}
		...
	}

	if !obj.DeletionTimestamp.IsZero() {
		controllerutil.RemoveFinalizer(obj, v2.HelmReleaseFinalizer)

Suspending is routine, and Cozystack gets there on its own rather than only by an operator running flux suspend:

$ git grep -n 'Spec.Suspend = true\|Spec.Suspend = existing' origin/main -- internal/
internal/controller/fluxplunger/flux_plunger.go:258:	latestHR.Spec.Suspend = true
internal/operator/package_reconciler.go:454:	hr.Spec.Suspend = existing.Spec.Suspend

The plunger sets it, and the operator carries it across every subsequent update. The two halves compound with the finding above: the admin deletes the Package, the HelmRelease disappears in milliseconds with nothing uninstalled, and the wait reports the destructive part finished while every workload, CRD and custom resource is still there. Nothing on the page lets the reader tell that apart from success. One sentence telling them to check spec.suspend first, and that a returned wait is not by itself proof, would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f621e84. The same if !obj.Spec.Suspend is in helm-controller v1.4.3, which v1.0 through v1.4 run, so it applies to every page in this PR and not only the ones on v1.5.0. The listing before the delete prints SUSPENDED and the page says to clear it first; the wait paragraph now ends with "a returned wait says the HelmRelease is gone, not that the uninstall ran". The PR body carried the same flat claim and is rewritten.

Then delete the Package object. `kubectl get packages` lists the names.

{{% alert title="Warning" color="warning" %}}
Deleting the Package uninstalls the component's Helm release, and that destroys more than the workloads. Anything the chart rendered as an ordinary template without `helm.sh/resource-policy: keep` goes with the release, CRDs and namespaces included, and Kubernetes deletes every custom resource of those CRD kinds along with them. Removing `cozystack.metallb` takes every CRD the MetalLB chart bundles, subcharts included, and with them every custom resource of those kinds cluster-wide; removing `cozystack.cozystack-basics` takes the `cozy-public` namespace and everything stored in it. Back up anything you still need first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] resource-policy: keep is not the whole survival rule from v1.5.0, and this PR's own edit is the counterexample

The Warning gives the annotation as the only thing that keeps an object alive through the uninstall. Since v1.5.0 there is a second, independent mechanism, and the rule as stated predicts the opposite outcome for the object this PR just removed from that sentence.

$ git show origin/main:packages/core/platform/templates/deletion-protection.yaml
  ValidatingAdmissionPolicy that blocks DELETE on any object carrying the
  platform.cozystack.io/no-delete=true label.
...
  validations:
  - expression: "false"
...
  validationActions: [Deny]
$ git log --format=%H -1 --diff-filter=A -- packages/core/platform/templates/deletion-protection.yaml
0c17d1870cef5a51a891928730d470ea556b1c8d
$ git tag --contains 0c17d1870 | sort -V | head -1
v1.5.0

The tenant-root Namespace carries that label and no keep (the keep a few lines below is on the sibling HelmRelease), which is exactly why the v1.5, v1.6 and next copies correctly drop it from the list one sentence after a rule saying it should go:

$ git show origin/main:packages/system/cozystack-basics/templates/tenant-root.yaml | head -8
kind: Namespace
metadata:
  name: tenant-root
  labels:
    platform.cozystack.io/no-delete: "true"

It matters past that one example, because the rule is what a reader applies to other components. Two more labelled-and-unkept objects belong to removable packages:

$ for f in packages/system/linstor/templates/cluster.yaml packages/system/cert-manager-issuers/templates/cluster-issuers.yaml; do
>   echo "$f no-delete=$(git show origin/main:$f | grep -c no-delete) keep=$(git show origin/main:$f | grep -c resource-policy)"; done
packages/system/linstor/templates/cluster.yaml no-delete=1 keep=0
packages/system/cert-manager-issuers/templates/cluster-issuers.yaml no-delete=3 keep=0

An operator removing cozystack.linstor or cozystack.cert-manager concludes from this page that those go with the release. What they get is an admission denial partway through the uninstall, a different end state with a different recovery. Naming the label next to the annotation, and pointing at the documented bypass (kubectl label <kind> <name> platform.cozystack.io/no-delete-), is enough. The v1.0 to v1.4 copies are accurate as written.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f621e84, on v1.5, v1.6 and next only, since v1.0 through v1.4 predate the policy. The Warning names the label next to the annotation, gives kubectl get <kind> --all-namespaces --selector platform.cozystack.io/no-delete=true for finding what carries it, and kubectl label <kind> <name> --namespace <ns> platform.cozystack.io/no-delete- for taking it off, with the consequence spelled out. cozystack.cozystack-basics labels the tenant-root Namespace and the tenant-root HelmRelease, the HelmRelease is kept by its annotation, and unlabelling the Namespace hands the root tenant to the uninstall. The label has to come off every labelled object in a release, which the cert-manager-issuers release of cozystack.cert-manager makes concrete with three ClusterIssuers.

Chasing your point further turned up a worse outcome than "the object stays". Uninstall.Run bails on any error out of deleteRelease (pkg/action/uninstall.go:122-125), and reconcileReleaseDeletion passes that error up, so the finalizer stays on for as long as the denial does. So removing cozystack.cozystack-basics on v1.5+ leaves the HelmRelease sitting in deletion and the kubectl wait on the page runs to its timeout. The Warning says that now, not just that the object survives.

On v1.0 the platform does not annotate the Packages it renders with `helm.sh/resource-policy: keep`, so adding a name to `disabledPackages` also removes the component when it is already installed. The next platform reconcile drops the Package, the operator's ownerReference takes the component's HelmRelease with it, and Flux uninstalls the release. There is no second command and no confirmation step, so back up anything you still need before making that edit.

{{% alert title="Warning" color="warning" %}}
Uninstalling the component's Helm release destroys more than the workloads. Anything the chart rendered as an ordinary template without `helm.sh/resource-policy: keep` goes with the release, CRDs and namespaces included, and Kubernetes deletes every custom resource of those CRD kinds along with them. Removing `cozystack.metallb` takes every CRD the MetalLB chart bundles, subcharts included, and with them every custom resource of those kinds cluster-wide; removing `cozystack.cozystack-basics` takes the `cozy-public` and `tenant-root` namespaces and everything stored in them, which is every application in the root tenant. Back up anything you still need first.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] the v0 tree keeps the bare command and gets none of the warning

Out of this diff, and pre-existing, so a follow-up PR is a fine resolution. Raising it here because it is the same gap you folded v1.0 in to close last round, and v0 is the last tree still carrying the original line:

$ grep -n 'kubectl delete hr' content/en/docs/v0/operations/configuration/components.md
66:kubectl delete hr -n <namespace> <component>
$ grep -c '^content/en/docs/v0/' /tmp/...-files.txt
0

The page is published and carries no deprecation banner:

$ sed -n '236,239p' hugo.yaml
    - version: "v0"
      url: "/docs/v0/"
      id: "v0"
      order: 1

The destruction the Warning describes belongs to helm uninstall, not to Package objects, so it applies to v0 in full: at v0.41.11 the MetalLB chart renders its CRDs as ordinary templates with no resource-policy, and a v0 reader following line 66 loses every IPAddressPool, L2Advertisement and BGPPeer cluster-wide. "v0 predates Package objects" excuses the recipe, not the missing warning.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Folded in here rather than deferred, at f621e84. v0 keeps its recipe and gets the warning ahead of the command. I checked charts/metallb/charts/crds/templates/crds.yaml at v0.1.0, v0.10.0, v0.20.0, v0.30.0 and v0.41.11: no resource-policy at any of them, so the destruction is the same on every 0.x line. The v0 copy drops the cozystack.cozystack-basics half of the sentence, that package does not exist before v1.

Regular bundle components can, on the other hand, be disabled (excluded) from the installation, when you don't need them.

Use `bundles.enabledPackages` and `bundles.disabledPackages` in the Platform Package values.
Every entry in those lists is a fully-qualified Package name under the `cozystack.` prefix — run `kubectl get package` to see the exact names on your cluster.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MINOR] kubectl get package cannot list a name that is not enabled yet

The sentence offers kubectl get package as the discovery step for both enabledPackages and disabledPackages, and it only answers for the second. An optional package has no Package object until its name is already in enabledPackages (packages/core/platform/templates/_helpers.tpl renders it under if and (has $name $enabled) (not (has $name $disabled))), and cozystack.hetzner-robotlb, the worked example right below, is one of those. PackageSources are rendered unconditionally from sources/*.yaml, so kubectl get packagesource answers for both lists.

The same wording is already on v1.2 through next unchanged, so fixing it is a sweep rather than a change to this hunk.

Separately on the v1.0 page: "Disabling components must be done before installing Cozystack." still sits directly above the new paragraph explaining that the same edit removes an already-installed component.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f621e84, swept across v1.1 through next rather than only the hunk. The discovery step is kubectl get packagesource now, with a clause saying kubectl get package answers only for disabledPackages. The and (has $name $enabled) (not (has $name $disabled)) gate is in _helpers.tpl from v1.1.0 on, and templates/sources.yaml globs sources/*.yaml unconditionally at all of those tags. The v1.0 line you noted at the end is gone as well: that page opens with "Disabling a component before installing Cozystack keeps it out of the installation entirely".

@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/component-removal-recipe branch 3 times, most recently from 54cafa6 to af929c0 Compare September 22, 2026 21:07
Removing an installed component was documented as `kubectl delete hr
-n <namespace> <component>`. The Package that emitted the release
owns it through a controller ownerReference, and the operator
watches HelmReleases with Owns(), so the delete fires the reconcile
that recreates it. The command reports success, nothing logs an
error, and the component is running again afterwards.

That is not the harmless no-op it looks like. The operator sets no
uninstall policy on the HelmReleases it builds, so Flux's default
applies and an unsuspended release is uninstalled when the object
goes away. Anything the uninstall destroys is gone before the
reinstall brings the workloads back, and the restored release hides
that it happened. The page now says so, for the benefit of anyone
who already ran the old recipe.

Deleting the cluster-scoped Package is what cascades into the
uninstall deliberately. It needs the disabledPackages entry to land
first, or the next platform upgrade renders the Package again and
the removal undoes itself one level up. The reader confirms that by
reading the disabledPackages list back off the platform HelmRelease,
which is the only observation here that cannot go green early. A
condition wait returns instantly against the Ready left standing by
the previous reconcile; re-reading the Package after deleting it
returns NotFound unconditionally; and comparing observedGeneration
against generation on that HelmRelease is equal in exactly the
window that matters, because the operator has not yet copied the
edit across from the Package the reader changed.

What the uninstall takes is stated above the command rather than
after it, in a warning callout. Anything the chart rendered as an
ordinary template and did not annotate resource-policy: keep goes
with the release, CRDs and namespaces alike, and the apiserver
cascades every custom resource of those CRD kinds. Removing
cozystack.metallb takes the MetalLB CRDs and the objects of those
kinds cluster-wide; removing cozystack.cozystack-basics takes the
cozy-public namespace, which that chart renders as a plain template
and which is not its install namespace. The one exception is the
namespace a component installs into: the operator applies that
itself, outside the release, so the uninstall never had it.

The v1.1 and v1.2 pages qualify the step count in place, next to the
instruction it governs, because the behaviour changed inside those
lines rather than between them. The keep annotation on Packages
arrived in v1.1.5 and v1.2.1 as backports, so v1.1.0 through v1.1.4
and v1.2.0 have no annotation and adding the name to
disabledPackages removes the component by itself, putting the
destruction on that step instead of the delete. The clauses name
their range rather than saying "and earlier", which would be false
across lines: v1.1.5 through v1.1.7 are lower-versioned than v1.2.0
and do carry the annotation.

The same pages offered `kubectl get package` as the way to find the
names for both lists, and it answers for one. An optional package is
rendered under `and (has $name $enabled) (not (has $name
$disabled))`, so it has no Package object until its name is already
in enabledPackages. PackageSources are rendered unconditionally from
sources/*.yaml, so `kubectl get packagesource` answers for both.

Applied to next and v1.1 through v1.6. Left out v1.0, whose line
never received the annotation at all, so that page needs the
single-step correction rather than this one. v0 predates Package
objects: its platform chart renders HelmReleases directly, so
deleting the HelmRelease there names the object the chart creates.

The v1.1 disabledPackages examples listed bare names, which match
nothing against the fully-qualified names the platform chart feeds
the helper. Both copies are fixed: the one in the components page,
and the one in the Hetzner install guide that page links to as its
worked example. Leaving the second would have had v1.1 teach the
qualified form on one page and hand out the broken form on the page
it cites, on the one provider where swapping MetalLB for RobotLB is
mandatory.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei
Aleksei Sviridkin (lexfrei) force-pushed the fix/component-removal-recipe branch 4 times, most recently from e3ad79e to 185073e Compare September 22, 2026 21:23
The named CRD kinds in the warning cover the top-level MetalLB chart
only; a subchart such as frr-k8s brings its own group, so the list
undercounts what an uninstall takes. Point at the chart instead of
enumerating kinds, which also stops the list needing maintenance per
component.

Package carries no finalizer, so deleting it returns before the Helm
uninstall that follows it has done anything. Add the wait that tells an
operator the destructive part is over.

That wait needs a HelmRelease name and a namespace, and neither follows
from the Package name: both come from the PackageSource install block,
and one Package can render several releases. The operator labels every
HelmRelease it builds with the Package that owns it, so listing by that
label before the delete produces the values and enumerates the
releases. The same listing prints spec.suspend, because helm-controller
skips the uninstall for a suspended HelmRelease and only drops its
finalizer, which would leave the component's objects behind with
nothing managing them. `kubectl wait --for=delete` also exits 0 for a
name that never existed, so the page says to take the values from the
listing rather than guess them.

Through v1.4 the cozystack-basics chart also renders the tenant-root
namespace as a plain template with no keep annotation, so uninstalling
it takes the whole root tenant, not just cozy-public. From v1.5 that
namespace carries the no-delete label the platform's admission policy
enforces, so the later pages keep the shorter example. The annotation
is not the whole survival rule on those pages either: the policy denies
DELETE on any labelled object, so the v1.5, v1.6 and next warnings name
the label beside the annotation and give the way to drop it.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
v1.0 documented `kubectl delete hr` as the way to remove an installed
component, with no warning about what an uninstall takes with it. That
command destroys the CRDs and every custom resource of those kinds, then
the Package recreates the HelmRelease and the chart reinstalls, so the
workloads come back and the data does not.

v1.0 needs a different correction from later versions rather than a copy
of it: the platform does not annotate rendered Packages with
`helm.sh/resource-policy: keep`, so `disabledPackages` alone already
removes an installed component there, in one step and with no
confirmation.

Because the removal starts when the operator picks that edit up, the
completion signal has to be set up before the edit: the label selector
that finds the component's HelmReleases needs the Package name, and the
Package goes away with the component. The page puts the listing ahead of
the edit for that reason, and keeps the same suspend check and delete
wait the later versions use.

Also qualify the package names in the Hetzner and components examples.
`disabledPackages` and `enabledPackages` are matched against the full
`cozystack.<name>` form, so the bare names did nothing.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The v0 removal recipe deletes the component's Helm release and says
nothing about what the uninstall takes with it. The MetalLB chart
renders its CRDs as ordinary subchart templates with no
helm.sh/resource-policy annotation on every 0.x line, so the uninstall
drops them and the apiserver cascades every custom resource of those
kinds cluster-wide.

Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei

Copy link
Copy Markdown
Contributor Author

IvanHunters All five are fixed in f621e84. v0 went in here instead of a follow-up, it was two lines.

v1.0, v1.1 and v1.2 have the listing and the wait now. On the single-step paths the page says to take the listing before the values edit, since the Package the selector needs goes away with the component.

The PR body is rewritten. The i18n line already said it passes and it still does. On the condition wait I think we agree on the mechanism and I stated mine too loosely: at the point that sentence is about, the reader has just edited the Package and the operator has not copied the values into the HelmRelease yet, so metadata.generation has not moved and the wait returns against the Ready from the previous reconcile. After the read-back it is a real gate, as you say. The body now spells the generation part out instead of asserting the conclusion, and the read-back stays as the one check on that step.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants